@znmin/claude-remote-cli 0.1.0 → 0.2.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 +72 -15
- package/THIRD_PARTY_NOTICES.md +3 -1
- package/package.json +6 -3
- package/scripts/postinstall.js +2 -1
- package/src/cli.js +47 -9
- package/src/client.js +25 -16
- package/src/local/browser.js +108 -0
- package/src/local/processes.js +76 -0
- package/src/local/server.js +141 -0
- package/src/local/tunnel.js +50 -0
- package/src/local/workspace.js +117 -0
- package/src/local-mode.js +94 -0
- package/src/remote-claude.js +39 -0
- package/src/ssh.js +16 -0
- package/src/system.js +7 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# claude-remote-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在项目目录运行 `claude`,让 Claude Code 主进程运行在 SSH 服务器,文件、终端和浏览器操作在本机执行;也保留服务器执行和目录双向同步模式。
|
|
4
4
|
|
|
5
5
|
本项目是独立的 SSH/同步封装,不是 Anthropic 官方客户端。它不提供服务器、Claude 账号或 API 服务。
|
|
6
6
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
|
-
要求:Node.js **20.11 或更高版本**、npm
|
|
9
|
+
要求:Node.js **20.11 或更高版本**、npm 和 OpenSSH 客户端。本地模式需要 `ssh`,服务器同步模式另外需要 `scp` 和系统 `tar`。支持 macOS/Linux 的 x64、arm64;Windows 请在 WSL 内使用。本地无需 Python、Homebrew 或 Anthropic 的 Claude Code。
|
|
10
10
|
|
|
11
11
|
npm 包名为 `@znmin/claude-remote-cli`,安装后提供 `claude` 和 `claude-remote` 命令。
|
|
12
12
|
|
|
@@ -25,20 +25,27 @@ 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.2.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.2.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
37
|
如果用户已经安装其他提供 `claude` 命令的软件,应先卸载旧客户端,避免 npm 命令冲突或 PATH 指向旧程序。无需使用 `--force` 覆盖。
|
|
38
38
|
|
|
39
39
|
## 开始使用
|
|
40
40
|
|
|
41
|
-
服务器需要 Linux/macOS、可用的 SSH/SCP
|
|
41
|
+
服务器需要 Linux/macOS、可用的 SSH/SCP 和 Bash。启动时会检查 Claude Code:
|
|
42
|
+
|
|
43
|
+
- 已安装:直接使用已有程序。
|
|
44
|
+
- 默认 `claude` 命令不存在:通过服务器上的 `curl` 或 `wget` 下载并执行 [Anthropic 官方原生安装器](https://github.com/anthropics/claude-code#get-started),无需服务器安装 Node.js,也不会使用 `sudo`。
|
|
45
|
+
- 安装完成:检查命令已可用,再开始目录同步并启动 Claude。首次启动时按 Claude 的提示完成账号认证;不会复制本机账号凭据。
|
|
46
|
+
- 下载失败、安装失败或缺少 `curl`/`wget`:停止启动并报告原因,不建立新的项目同步会话。自动安装最多等待 10 分钟,可修复网络/权限后重试。
|
|
47
|
+
|
|
48
|
+
安装过程只在默认命令缺失时触发,不会自动重装或升级已有 Claude Code。
|
|
42
49
|
|
|
43
50
|
```bash
|
|
44
51
|
# 配置你的服务器,不会立即连接或上传文件
|
|
@@ -60,13 +67,60 @@ claude --continue
|
|
|
60
67
|
|
|
61
68
|
未配置服务器时,也可以直接运行 `claude`,按终端提示输入服务器地址和端口。请勿把密码或私钥填入配置;登录使用 OpenSSH 原有认证流程和主机身份验证。
|
|
62
69
|
|
|
63
|
-
|
|
70
|
+
若需要使用特定的 Claude 可执行文件,可指定绝对路径;自定义路径不存在时会提示修正配置,不会自动改用其他程序:
|
|
64
71
|
|
|
65
72
|
```bash
|
|
66
73
|
claude-remote configure my-server --claude /absolute/path/to/claude
|
|
67
74
|
```
|
|
68
75
|
|
|
69
|
-
##
|
|
76
|
+
## 本地执行模式(0.2.0)
|
|
77
|
+
|
|
78
|
+
新配置默认使用 `local`;旧配置保持 `remote`,不会静默改变已有行为。两种模式都在服务器运行真实 Claude Code。
|
|
79
|
+
|
|
80
|
+
| 模式 | 项目文件、命令、依赖和测试 | 浏览器 |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| `local` | 直接在本机操作,不通过该模式同步项目 | 本机独立受控 Chromium |
|
|
83
|
+
| `remote` | 文件双向同步,工具在服务器执行 | 使用服务器原有工具 |
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
claude-remote mode local # 保存默认模式
|
|
87
|
+
claude --local # 仅本次使用本地工具
|
|
88
|
+
claude --remote # 仅本次使用旧的服务器模式
|
|
89
|
+
claude-remote configure my-server --mode local
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
切换模式不会自动停止已有后台同步。若从旧模式切换且不希望旧项目继续同步,先运行 `claude-remote stop --all`。本地模式本身不创建同步会话,不上传整个项目。
|
|
93
|
+
|
|
94
|
+
无需用户手工配置 MCP,默认提供 18 个工具:
|
|
95
|
+
|
|
96
|
+
- 项目与文件:`workspace_info`、`read_file`、`write_file`、`edit_file`、`list_directory`、`search_text`。
|
|
97
|
+
- 本地终端:`run_command`、`process_status`、`list_processes`、`stop_process`。
|
|
98
|
+
- 浏览器:`browser_open`、`browser_snapshot`、`browser_click`、`browser_fill`、`browser_press`、`browser_screenshot`、`browser_logs`、`browser_close`。
|
|
99
|
+
|
|
100
|
+
Git、npm、pnpm、Python、Go、Docker、数据库 CLI、`gh` 等共用本地终端,不需要逐项适配,仍需在本机安装并具备必要的登录、连接配置及权限。终端继承启动用户环境和登录 shell,默认工作目录为当前项目。长任务使用后台进程,再查询日志和停止;退出会话会停止仍受本工具管理的进程组,自行 daemonize 或逃离原进程组的程序不在自动清理保证内。
|
|
101
|
+
|
|
102
|
+
终端工具运行非交互命令,不提供交互式 stdin/PTY;需要交互登录的 CLI,请先在本机终端登录。原生 `@文件` 补全和 IDE 索引没有自动映射到本地,项目文件通过本地 MCP 工具访问。
|
|
103
|
+
|
|
104
|
+
文件工具限定项目内,拒绝路径穿越和指向项目外的符号链接;修改已有文件需要精确旧文本或读取时的 SHA-256。终端具有启动用户的正常系统权限,项目工作目录不是操作系统沙箱。本地模式可使用本机 Git worktree。
|
|
105
|
+
|
|
106
|
+
浏览器使用独立窗口和临时上下文,不读取日常 Chrome 的登录态。启动本地模式时准备相应浏览器版本,后续使用缓存;收到浏览器工具调用时才打开窗口。可以打开本机 localhost,填写表单、点击、截图和读取控制台错误。
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
claude-remote install-browser
|
|
110
|
+
claude-remote configure my-server --headless # 无窗口
|
|
111
|
+
claude-remote configure my-server --headed # 恢复可见窗口
|
|
112
|
+
CLAUDE_REMOTE_HEADLESS=1 claude --local
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Linux 上仍需安装 Playwright 所需的系统共享库,有窗口模式还需图形环境。工具不会自动以 sudo 安装系统依赖;缺失时按 Playwright 的错误提示配置。
|
|
116
|
+
|
|
117
|
+
本地模式要求服务器允许 SSH TCP 转发,Claude Code 版本至少 **2.1.248**;已有版本过旧时会提示在服务器运行 `claude update`。每个会话创建仅监听本机回环地址的工具服务、随机令牌和 SSH 反向隧道;令牌通过 SSH 标准输入写入服务器 0600 临时配置,不出现在命令行参数或同步项目中。退出时清理配置、连接和浏览器。
|
|
118
|
+
|
|
119
|
+
启动时使用 `--restricted`、`--tools ""`、`--strict-mcp-config`,限定工具和设置来源、关闭普通 hooks 和自定义命令。保留服务器上的订阅认证,不使用跳过 OAuth 的 `--bare`。本地连接失效时不会退回服务器工具。支持 `--continue`、`--resume`、`--model`、`--effort`、`-p` 和输入/输出格式;暂不接受替换工具配置、加载插件、切换云会话等参数。
|
|
120
|
+
|
|
121
|
+
这是工具路由约束,不是对 Claude Code 主进程的系统隔离。认证、会话和日志仍在服务器,管理员管理策略继续生效。文件内容、命令结果和截图会经过服务器交给模型,不整体同步不代表数据不离开本机。
|
|
122
|
+
|
|
123
|
+
## 同步管理(仅服务器模式)
|
|
70
124
|
|
|
71
125
|
```bash
|
|
72
126
|
claude-remote status # 查看全部项目、同步错误和文件冲突
|
|
@@ -87,7 +141,7 @@ claude-remote --help
|
|
|
87
141
|
CLAUDE_REMOTE_HOST=another-server CLAUDE_REMOTE_PORT=2222 claude
|
|
88
142
|
```
|
|
89
143
|
|
|
90
|
-
##
|
|
144
|
+
## 文件同步行为(仅服务器模式)
|
|
91
145
|
|
|
92
146
|
- 每台本机、每个 SSH 地址/端口、每个项目目录有独立的稳定编号,远程默认映射到 `~/.local/share/claude-remote/projects/<编号>`。启动时会显示两端路径。移动项目或更换 SSH 别名会产生新的编号。
|
|
93
147
|
- 启动远程 Claude 前等待首次同步,退出时也尝试完成一次同步。连接或最终同步失败会返回非零退出码;会话保留以便检查和重连。
|
|
@@ -105,15 +159,18 @@ CLAUDE_REMOTE_HOST=another-server CLAUDE_REMOTE_PORT=2222 claude
|
|
|
105
159
|
|
|
106
160
|
| 变量 | 用途 |
|
|
107
161
|
| --- | --- |
|
|
162
|
+
| `CLAUDE_REMOTE_MODE` | 临时选择 `local` 或 `remote` |
|
|
163
|
+
| `CLAUDE_REMOTE_HEADLESS` | `1` 无窗口浏览器,`0` 可见窗口,覆盖配置 |
|
|
108
164
|
| `CLAUDE_REMOTE_HOST` | 临时 SSH 别名或 user@host |
|
|
109
165
|
| `CLAUDE_REMOTE_PORT` | 临时 SSH 端口 |
|
|
110
166
|
| `CLAUDE_REMOTE_CONFIG_DIR` | 配置和映射索引目录,默认 `~/.config/claude-remote` |
|
|
111
167
|
| `CLAUDE_REMOTE_CACHE_DIR` | 下载缓存父目录,默认 `~/.cache/claude-remote` |
|
|
112
168
|
| `CLAUDE_REMOTE_MUTAGEN` | 使用自行准备的 Mutagen 可执行文件绝对路径(应为 0.18.1,并放置配套代理包) |
|
|
113
|
-
| `
|
|
169
|
+
| `CLAUDE_REMOTE_PREPARE_SYNC=1` | npm 安装阶段提前准备同步工具,默认按需准备 |
|
|
170
|
+
| `CLAUDE_REMOTE_SKIP_DOWNLOAD=1` | 跳过安装阶段的同步工具准备 |
|
|
114
171
|
| `CLAUDE_REMOTE_DEBUG=1` | 出错时显示调试信息 |
|
|
115
172
|
|
|
116
|
-
`npm install --ignore-scripts`
|
|
173
|
+
`npm install --ignore-scripts` 也受支持,首次使用各模式时准备对应组件。无法访问 GitHub 时可自行准备 Mutagen 并使用 `CLAUDE_REMOTE_MUTAGEN`。浏览器缓存由 Playwright 管理,支持其 `PLAYWRIGHT_BROWSERS_PATH` 环境变量。
|
|
117
174
|
|
|
118
175
|
卸载:先用 `claude-remote stop --all` 停止后台同步,再 `npm uninstall -g @znmin/claude-remote-cli`。npm 卸载保留本地配置、缓存和两端项目文件。
|
|
119
176
|
|
|
@@ -123,11 +180,11 @@ CLAUDE_REMOTE_HOST=another-server CLAUDE_REMOTE_PORT=2222 claude
|
|
|
123
180
|
npm ci --ignore-scripts
|
|
124
181
|
npm test
|
|
125
182
|
|
|
126
|
-
#
|
|
127
|
-
CLAUDE_REMOTE_INTEGRATION=1 npm test
|
|
183
|
+
# 可选:真实本地同步与受控浏览器测试
|
|
184
|
+
CLAUDE_REMOTE_INTEGRATION=1 CLAUDE_REMOTE_BROWSER_TEST=1 npm test
|
|
128
185
|
|
|
129
186
|
npm pack
|
|
130
|
-
npm install -g ./znmin-claude-remote-cli-0.
|
|
187
|
+
npm install -g ./znmin-claude-remote-cli-0.2.0.tgz
|
|
131
188
|
|
|
132
189
|
# 确定包名、版本和使用条款后,使用有发布权限的 npm 账号发布
|
|
133
190
|
npm login
|
|
@@ -136,6 +193,6 @@ npm publish --access public
|
|
|
136
193
|
|
|
137
194
|
GitHub 仓库名为 `claude-remote-cli`,npm 包名为 `@znmin/claude-remote-cli`,命令名为 `claude` 和 `claude-remote`。发布账号必须拥有 npm 用户作用域 `@znmin`,或拥有同名 npm 组织的发布权限;GitHub 身份不会自动授予 npm 权限。当前 `license` 为 `UNLICENSED`,尚未指定开源许可证。
|
|
138
195
|
|
|
139
|
-
|
|
196
|
+
测试覆盖真实 MCP HTTP 调用、本机文件与进程、受控浏览器交互、本地真实同步、模拟 SSH/Claude 的完整启动清理链路,以及官方安装流程的隔离模拟。尚未提供服务器,真实 SSH 认证、Claude 模型端到端调用、Linux 和其他 CPU 架构尚未实测。
|
|
140
197
|
|
|
141
198
|
参考:[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/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Third-party components
|
|
2
2
|
|
|
3
|
-
The npm package contains the JavaScript CLI source and depends on `proper-lockfile` (MIT) and
|
|
3
|
+
The npm package contains the JavaScript CLI source and depends on `proper-lockfile` (MIT), the official `@modelcontextprotocol/sdk` (MIT), `zod` (MIT), `playwright` (Apache-2.0), and their npm-managed dependencies. Their license files are included in their respective npm packages.
|
|
4
|
+
|
|
5
|
+
Playwright downloads its matching Chromium/browser components on first local-mode preparation. These components remain subject to their upstream licenses. A separate local browser context is used; this wrapper does not include the user's browser profile.
|
|
4
6
|
|
|
5
7
|
Mutagen binaries are **not bundled in this npm package**. At setup/first use, the tool can reuse an existing Mutagen 0.18.1 installation, or download the matching v0.18.1 archive from the official release repository. SHA-256 values are pinned in `src/installer.js` from the official release's `SHA256SUMS` file.
|
|
6
8
|
|
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
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Run Claude Code on an SSH server with local filesystem, terminal and browser tools, or remote bidirectional sync",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"engines": {
|
|
@@ -43,7 +43,10 @@
|
|
|
43
43
|
"registry": "https://registry.npmjs.org/"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"
|
|
46
|
+
"@modelcontextprotocol/sdk": "1.32.2",
|
|
47
|
+
"playwright": "1.64.1",
|
|
48
|
+
"proper-lockfile": "4.1.2",
|
|
49
|
+
"zod": "4.6.7"
|
|
47
50
|
},
|
|
48
51
|
"repository": {
|
|
49
52
|
"type": "git",
|
package/scripts/postinstall.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ensureMutagen } from '../src/installer.js';
|
|
2
2
|
|
|
3
|
-
if (process.env.CLAUDE_REMOTE_SKIP_DOWNLOAD !== '1') {
|
|
3
|
+
if (process.env.CLAUDE_REMOTE_PREPARE_SYNC === '1' && process.env.CLAUDE_REMOTE_SKIP_DOWNLOAD !== '1') {
|
|
4
4
|
try {
|
|
5
5
|
await ensureMutagen();
|
|
6
6
|
console.log('claude 已安装。运行 claude-remote configure 用户名@服务器,或直接运行 claude 开始配置。');
|
|
@@ -9,3 +9,4 @@ if (process.env.CLAUDE_REMOTE_SKIP_DOWNLOAD !== '1') {
|
|
|
9
9
|
console.warn(`CLI 已安装,同步工具准备未完成:${error.message}\n首次连接时会重试。`);
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
console.log('安装完成:运行 claude 配置服务器并启动。新配置默认本地执行;同步工具和浏览器在对应模式首次使用时自动准备。');
|
package/src/cli.js
CHANGED
|
@@ -3,31 +3,41 @@ import { createInterface } from 'node:readline/promises';
|
|
|
3
3
|
import { Client, DEFAULTS } from './client.js';
|
|
4
4
|
import { cachedBinary, ensureMutagen, VERSION } from './installer.js';
|
|
5
5
|
import { exists, readJSON, which } from './system.js';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
|
+
|
|
10
|
+
const HELP = `${PACKAGE.name} ${PACKAGE.version}(非 Anthropic 官方客户端)
|
|
8
11
|
|
|
9
12
|
安装后使用:
|
|
10
13
|
claude 首次引导设置服务器,之后启动远程 Claude Code
|
|
11
14
|
claude --continue 将参数传给服务器上的 Claude
|
|
15
|
+
claude --local 本次使用本地文件、终端和受控浏览器
|
|
16
|
+
claude --remote 本次使用服务器工具与双向同步
|
|
12
17
|
claude-remote configure [user@host] [--port 2222] [--claude /路径/claude]
|
|
18
|
+
claude-remote mode local|remote 切换默认执行模式
|
|
13
19
|
claude-remote status 查看全部同步状态和冲突
|
|
14
20
|
claude-remote flush 等待当前目录同步完成
|
|
15
21
|
claude-remote stop [--all | --name 名称]
|
|
16
22
|
claude-remote doctor 检查本地环境,不连接服务器
|
|
17
23
|
claude-remote install-sync 准备同步工具(通常自动完成)
|
|
24
|
+
claude-remote install-browser 准备本地受控浏览器(本地模式自动完成)
|
|
18
25
|
claude-remote run [Claude 参数] 启动远程 Claude
|
|
19
26
|
|
|
20
|
-
configure 可选:--root 远程根目录、--ignore 忽略规则(可重复)。
|
|
21
|
-
|
|
27
|
+
configure 可选:--mode local|remote、--headless/--headed、--root 远程根目录、--ignore 忽略规则(可重复)。
|
|
28
|
+
新配置默认使用本地执行;旧配置保持服务器模式,直到显式切换。
|
|
29
|
+
服务器缺少默认 Claude Code 时会自动安装;首次启动需完成认证。
|
|
30
|
+
此工具不提供服务器或 Claude 账号。
|
|
22
31
|
退出 Claude 后会继续后台同步;停止请运行 claude-remote stop。
|
|
23
32
|
`;
|
|
24
33
|
|
|
25
34
|
async function configure(client, args) {
|
|
26
35
|
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
|
|
27
36
|
port: { type: 'string' }, root: { type: 'string' }, claude: { type: 'string' },
|
|
28
|
-
ignore: { type: 'string', multiple: true }
|
|
37
|
+
ignore: { type: 'string', multiple: true }, mode: { type: 'string' }, headless: { type: 'boolean' }, headed: { type: 'boolean' }
|
|
29
38
|
} });
|
|
30
39
|
if (positionals.length > 1) throw new Error('仅接受一个 SSH 别名或 user@host。');
|
|
40
|
+
if (values.headless && values.headed) throw new Error('--headless 和 --headed 不能同时使用。');
|
|
31
41
|
let host = positionals[0], port = values.port;
|
|
32
42
|
if (!host) {
|
|
33
43
|
if (!process.stdin.isTTY) throw new Error('请指定服务器:claude-remote configure 用户名@服务器');
|
|
@@ -39,24 +49,47 @@ async function configure(client, args) {
|
|
|
39
49
|
} finally { prompt.close(); }
|
|
40
50
|
}
|
|
41
51
|
return client.configure({ host, port: port === undefined ? null : Number(port),
|
|
42
|
-
root: values.root || DEFAULTS.root, claude: values.claude || DEFAULTS.claude, ignores: values.ignore || []
|
|
52
|
+
root: values.root || DEFAULTS.root, claude: values.claude || DEFAULTS.claude, ignores: values.ignore || [],
|
|
53
|
+
mode: values.mode, browserHeadless: values.headed ? false : values.headless });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function executionOptions(args) {
|
|
57
|
+
const forwarded = []; let mode, literal = false;
|
|
58
|
+
for (const arg of args) {
|
|
59
|
+
if (arg === '--') literal = true;
|
|
60
|
+
if (!literal && (arg === '--local' || arg === '--remote')) {
|
|
61
|
+
const selected = arg.slice(2);
|
|
62
|
+
if (mode && mode !== selected) throw new Error('--local 和 --remote 不能同时使用。');
|
|
63
|
+
mode = selected;
|
|
64
|
+
} else forwarded.push(arg);
|
|
65
|
+
}
|
|
66
|
+
return { mode, forwarded };
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
export async function main(asClaude, args = process.argv.slice(2), client = new Client()) {
|
|
46
70
|
if (asClaude) {
|
|
71
|
+
const options = executionOptions(args);
|
|
72
|
+
args = options.forwarded;
|
|
73
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(HELP); return 0; }
|
|
74
|
+
if (args.length === 1 && ['--version', '-v'].includes(args[0])) { console.log(`${PACKAGE.name} ${PACKAGE.version}`); return 0; }
|
|
47
75
|
const saved = await readJSON(client.configFile, {});
|
|
48
76
|
if (!saved.host && !client.env.CLAUDE_REMOTE_HOST) {
|
|
49
77
|
if (args.includes('--help') || args.includes('-h')) { console.log(HELP); return 0; }
|
|
50
|
-
if (args.includes('--version')) { console.log(
|
|
78
|
+
if (args.includes('--version')) { console.log(`${PACKAGE.name} ${PACKAGE.version}`); return 0; }
|
|
51
79
|
if (process.stdin.isTTY) await configure(client, []);
|
|
52
80
|
}
|
|
53
|
-
|
|
81
|
+
const config = await client.settings();
|
|
82
|
+
return client.launch(options.mode ? { ...config, mode: options.mode } : config, args);
|
|
54
83
|
}
|
|
55
84
|
const [action, ...rest] = args;
|
|
56
85
|
if (!action || ['--help', '-h', 'help'].includes(action)) { console.log(HELP); return 0; }
|
|
57
|
-
if (['--version', '-v'].includes(action)) { console.log(
|
|
86
|
+
if (['--version', '-v'].includes(action)) { console.log(PACKAGE.version); return 0; }
|
|
58
87
|
if (action === 'configure') { await configure(client, rest); return 0; }
|
|
59
|
-
if (action === '
|
|
88
|
+
if (action === 'mode') {
|
|
89
|
+
if (rest.length !== 1) throw new Error('用法:claude-remote mode local|remote');
|
|
90
|
+
await client.setMode(rest[0]); return 0;
|
|
91
|
+
}
|
|
92
|
+
if (action === 'run') return main(true, rest[0] === '--' ? rest.slice(1) : rest, client);
|
|
60
93
|
if (action === 'stop') {
|
|
61
94
|
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true,
|
|
62
95
|
options: { all: { type: 'boolean' }, name: { type: 'string' } } });
|
|
@@ -71,6 +104,10 @@ export async function main(asClaude, args = process.argv.slice(2), client = new
|
|
|
71
104
|
await client.flush((await client.project(await client.settings())).name); return 0;
|
|
72
105
|
}
|
|
73
106
|
if (action === 'install-sync') { console.log('同步工具:' + await ensureMutagen({ env: client.env })); return 0; }
|
|
107
|
+
if (action === 'install-browser') {
|
|
108
|
+
const { installBrowser } = await import('./local/browser.js');
|
|
109
|
+
await installBrowser(client.log); console.log('本地受控浏览器已就绪。'); return 0;
|
|
110
|
+
}
|
|
74
111
|
if (action === 'doctor') {
|
|
75
112
|
console.log(`Node.js:${process.version}\n平台:${process.platform}/${process.arch}`);
|
|
76
113
|
for (const tool of ['ssh', 'scp', 'tar']) console.log(`${tool}:${await which(tool, client.env.PATH) || '未安装'}`);
|
|
@@ -78,6 +115,7 @@ export async function main(asClaude, args = process.argv.slice(2), client = new
|
|
|
78
115
|
console.log(`同步工具:${client.env.CLAUDE_REMOTE_MUTAGEN || (await exists(cached) ? cached : await which('mutagen', client.env.PATH)) || '首次使用时自动下载'}(目标版本 ${VERSION})`);
|
|
79
116
|
const config = await readJSON(client.configFile, {});
|
|
80
117
|
console.log(`配置:${client.configFile}\n服务器:${client.env.CLAUDE_REMOTE_HOST || config.host || '尚未配置'}`);
|
|
118
|
+
console.log(`默认模式:${client.env.CLAUDE_REMOTE_MODE || config.mode || 'remote(旧配置)'}`);
|
|
81
119
|
return 0;
|
|
82
120
|
}
|
|
83
121
|
throw new Error(`未知命令 ${action}。运行 claude-remote --help 查看用法。`);
|
package/src/client.js
CHANGED
|
@@ -4,6 +4,9 @@ import os from 'node:os';
|
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { execute, exists, quote, readJSON, shellJoin, which, withLock, writeJSON } from './system.js';
|
|
6
6
|
import { ensureMutagen } from './installer.js';
|
|
7
|
+
import { ensureRemoteClaude, remoteEnvironment as prefix } from './remote-claude.js';
|
|
8
|
+
import { sshArgs } from './ssh.js';
|
|
9
|
+
export { sshArgs } from './ssh.js';
|
|
7
10
|
|
|
8
11
|
export const DEFAULTS = {
|
|
9
12
|
root: '.local/share/claude-remote/projects', claude: 'claude',
|
|
@@ -11,6 +14,8 @@ export const DEFAULTS = {
|
|
|
11
14
|
};
|
|
12
15
|
|
|
13
16
|
export function validate(config) {
|
|
17
|
+
if (config.mode != null && !['local', 'remote'].includes(config.mode)) throw new Error('模式只能是 local 或 remote。');
|
|
18
|
+
if (config.browserHeadless != null && typeof config.browserHeadless !== 'boolean') throw new Error('browserHeadless 必须是布尔值。');
|
|
14
19
|
if (!/^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(config.host || ''))
|
|
15
20
|
throw new Error('服务器请填写 SSH 别名或 user@host,不要包含端口、空格或 SSH 参数。IPv6 请使用 SSH 别名。');
|
|
16
21
|
if (config.port != null && (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535))
|
|
@@ -28,16 +33,6 @@ export function validate(config) {
|
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export const remotePath = file => file.startsWith('/') ? quote(file) : '"$HOME"/' + quote(file);
|
|
31
|
-
const prefix = 'export PATH="$HOME/.local/bin:$PATH"; ';
|
|
32
|
-
|
|
33
|
-
export function sshArgs(config, script, { interactive = false, tty = false } = {}) {
|
|
34
|
-
const args = ['-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'];
|
|
35
|
-
if (!interactive) args.push('-n'); // Never consume piped Claude prompts during preflight.
|
|
36
|
-
if (tty) args.push('-tt');
|
|
37
|
-
if (config.port != null) args.push('-p', String(config.port));
|
|
38
|
-
args.push(config.host, 'bash -lc ' + quote(script));
|
|
39
|
-
return args;
|
|
40
|
-
}
|
|
41
36
|
|
|
42
37
|
export function assertSynchronized(current) {
|
|
43
38
|
if (!current) throw new Error('同步会话不存在。');
|
|
@@ -73,11 +68,12 @@ export class Client {
|
|
|
73
68
|
return withLock(this.lockFile, async () => {
|
|
74
69
|
const old = await readJSON(this.configFile, {});
|
|
75
70
|
const config = validate({ ...DEFAULTS, ...input,
|
|
71
|
+
mode: input.mode ?? old.mode ?? 'local', browserHeadless: input.browserHeadless ?? old.browserHeadless ?? false,
|
|
76
72
|
ignores: [...DEFAULTS.ignores, ...(input.ignores || [])],
|
|
77
73
|
client_id: old.client_id || randomUUID().replaceAll('-', '').slice(0, 12) });
|
|
78
74
|
await writeJSON(this.configFile, config);
|
|
79
75
|
this.log(`已配置服务器:${config.host}。进入项目目录后运行 claude。`);
|
|
80
|
-
this.log('
|
|
76
|
+
this.log(`当前模式:${config.mode === 'local' ? '本地执行' : '服务器执行与双向同步'}。旧后台同步不会自动停止,可用 claude-remote stop --all 停止。`);
|
|
81
77
|
return config;
|
|
82
78
|
});
|
|
83
79
|
}
|
|
@@ -88,6 +84,7 @@ export class Client {
|
|
|
88
84
|
const config = { ...DEFAULTS, ...stored };
|
|
89
85
|
if (this.env.CLAUDE_REMOTE_HOST) config.host = this.env.CLAUDE_REMOTE_HOST;
|
|
90
86
|
if (this.env.CLAUDE_REMOTE_PORT !== undefined) config.port = Number(this.env.CLAUDE_REMOTE_PORT);
|
|
87
|
+
config.mode = this.env.CLAUDE_REMOTE_MODE || config.mode || 'remote';
|
|
91
88
|
if (!config.host) throw new Error('尚未配置服务器。请运行:claude-remote configure 用户名@服务器(自定义端口加 --port 2222)。');
|
|
92
89
|
validate(config);
|
|
93
90
|
if (!config.client_id) {
|
|
@@ -115,6 +112,15 @@ export class Client {
|
|
|
115
112
|
host: config.host, port: config.port ?? null };
|
|
116
113
|
}
|
|
117
114
|
|
|
115
|
+
async setMode(mode) {
|
|
116
|
+
if (!['local', 'remote'].includes(mode)) throw new Error('模式只能是 local 或 remote。');
|
|
117
|
+
await withLock(this.lockFile, async () => {
|
|
118
|
+
const stored = await readJSON(this.configFile, {});
|
|
119
|
+
await writeJSON(this.configFile, { ...stored, mode });
|
|
120
|
+
});
|
|
121
|
+
this.log(`默认模式已设为 ${mode}。旧后台同步不会自动停止;如需停止请运行 claude-remote stop --all。`);
|
|
122
|
+
}
|
|
123
|
+
|
|
118
124
|
async prepare() {
|
|
119
125
|
if (!this.mutagen) this.mutagen = await this.ensureMutagen({ env: this.env });
|
|
120
126
|
return this.mutagen;
|
|
@@ -136,12 +142,16 @@ export class Client {
|
|
|
136
142
|
assertSynchronized((await this.sessions()).find(session => session.name === name));
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
async remote(config, script, interactive = false) {
|
|
145
|
+
async remote(config, script, interactive = false, { timeout = 180000 } = {}) {
|
|
140
146
|
return this.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && this.tty }),
|
|
141
|
-
{ env: this.env, timeout: interactive ? 0 :
|
|
147
|
+
{ env: this.env, timeout: interactive ? 0 : timeout, check: !interactive });
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
async launch(config, extra) {
|
|
151
|
+
if (config.mode === 'local') {
|
|
152
|
+
const { launchLocal } = await import('./local-mode.js');
|
|
153
|
+
return launchLocal(this, config, extra);
|
|
154
|
+
}
|
|
145
155
|
for (const tool of ['ssh', 'scp']) if (!await which(tool, this.env.PATH))
|
|
146
156
|
throw new Error(`找不到系统 ${tool},请安装 OpenSSH 客户端。`);
|
|
147
157
|
const item = await this.project(config);
|
|
@@ -162,9 +172,8 @@ export class Client {
|
|
|
162
172
|
if (inside(relative) || inside(reverse))
|
|
163
173
|
throw new Error(`目录与正在同步的项目重叠:${other}\n先运行:claude-remote stop --name ${session.name}`);
|
|
164
174
|
}
|
|
165
|
-
await this.remote(config,
|
|
166
|
-
|
|
167
|
-
'mkdir -p -- ' + remotePath(item.remote));
|
|
175
|
+
await this.remote(config, ensureRemoteClaude(config.claude) +
|
|
176
|
+
'mkdir -p -- ' + remotePath(item.remote), false, { timeout: 600000 });
|
|
168
177
|
if (existing.some(session => session.name === item.name)) {
|
|
169
178
|
await this.sync(['resume', item.name], { timeout: 60000, capture: !this.tty });
|
|
170
179
|
} else {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { chromium } from 'playwright';
|
|
6
|
+
import { execute, exists, withLock } from '../system.js';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
let preparation;
|
|
10
|
+
|
|
11
|
+
export async function installBrowser(log = console.error) {
|
|
12
|
+
if (!preparation) preparation = (async () => {
|
|
13
|
+
const cli = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
|
|
14
|
+
// Keep the lock outside Playwright's cache: its installer replaces browser directories.
|
|
15
|
+
const lock = path.join(process.env.CLAUDE_REMOTE_CACHE_DIR || path.join(os.homedir(), '.cache', 'claude-remote'), 'browser-install');
|
|
16
|
+
await withLock(lock, async () => {
|
|
17
|
+
log('正在准备本地受控浏览器(首次使用或升级时自动下载)……');
|
|
18
|
+
// The CLI checks completion markers and prepares headed AND headless binaries.
|
|
19
|
+
const result = await execute(process.execPath, [cli, 'install', 'chromium'], { capture: true, timeout: 600000 });
|
|
20
|
+
if (!await exists(chromium.executablePath())) throw new Error('浏览器安装未完成:' + result.stderr);
|
|
21
|
+
});
|
|
22
|
+
})().catch(error => { preparation = null; throw error; });
|
|
23
|
+
await preparation;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class Browser {
|
|
27
|
+
constructor({ headless = false, log = console.error } = {}) {
|
|
28
|
+
this.headless = headless; this.log = log; this.pages = new Map(); this.logs = new Map(); this.reference = 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async start() {
|
|
32
|
+
if (!this.starting) this.starting = (async () => {
|
|
33
|
+
await installBrowser(this.log);
|
|
34
|
+
this.browser = await chromium.launch({ headless: this.headless });
|
|
35
|
+
this.browser.on('disconnected', () => { this.starting = null; this.context = null; this.browser = null; this.pages.clear(); this.logs.clear(); });
|
|
36
|
+
this.context = await this.browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
37
|
+
})().catch(error => { this.starting = null; throw error; });
|
|
38
|
+
await this.starting;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
page(id) {
|
|
42
|
+
const page = this.pages.get(id);
|
|
43
|
+
if (!page || page.isClosed()) throw new Error('找不到浏览器页面,请先调用 browser_open。');
|
|
44
|
+
return page;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async open(url = 'about:blank') {
|
|
48
|
+
await this.start();
|
|
49
|
+
if (this.pages.size >= 16) throw new Error('浏览器页面过多,请先调用 browser_close 关闭部分页面。');
|
|
50
|
+
const page = await this.context.newPage(), id = randomUUID();
|
|
51
|
+
this.pages.set(id, page); this.logs.set(id, []);
|
|
52
|
+
const record = message => {
|
|
53
|
+
const entries = this.logs.get(id); if (!entries) return;
|
|
54
|
+
entries.push(message.slice(0, 1000)); if (entries.length > 100) entries.shift();
|
|
55
|
+
};
|
|
56
|
+
page.on('console', message => record(`${message.type()}: ${message.text()}`));
|
|
57
|
+
page.on('pageerror', error => record('pageerror: ' + error.message));
|
|
58
|
+
page.on('requestfailed', request => record(`requestfailed: ${request.url()} ${request.failure()?.errorText}`));
|
|
59
|
+
let navigationError;
|
|
60
|
+
try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); }
|
|
61
|
+
catch (error) { navigationError = error.message; record(error.message); }
|
|
62
|
+
return { ...await this.snapshot(id), ...(navigationError ? { navigation_error: navigationError } : {}) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async snapshot(id) {
|
|
66
|
+
const page = this.page(id), generation = ++this.reference;
|
|
67
|
+
const state = await page.evaluate(generation => {
|
|
68
|
+
const elements = [...document.querySelectorAll('a,button,input,textarea,select,[role="button"],[contenteditable="true"]')]
|
|
69
|
+
.filter(element => element.getClientRects().length).slice(0, 200).map((element, i) => {
|
|
70
|
+
const ref = `r${generation}-${i}`;
|
|
71
|
+
element.setAttribute('data-claude-remote-ref', ref);
|
|
72
|
+
return { selector: `[data-claude-remote-ref="${ref}"]`, tag: element.tagName.toLowerCase(),
|
|
73
|
+
text: (element.innerText || element.getAttribute('aria-label') || element.getAttribute('placeholder') || '').slice(0, 200),
|
|
74
|
+
type: element.getAttribute('type') || undefined };
|
|
75
|
+
});
|
|
76
|
+
return { text: (document.body?.innerText || '').slice(0, 12000), elements };
|
|
77
|
+
}, generation);
|
|
78
|
+
return { page_id: id, url: page.url(), title: await page.title(), ...state };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async action(id, action, selector, value) {
|
|
82
|
+
const page = this.page(id);
|
|
83
|
+
if (action === 'click') await page.locator(selector).click({ timeout: 15000 });
|
|
84
|
+
else if (action === 'fill') await page.locator(selector).fill(value, { timeout: 15000 });
|
|
85
|
+
else if (action === 'press') {
|
|
86
|
+
if (selector) await page.locator(selector).press(value, { timeout: 15000 });
|
|
87
|
+
else await page.keyboard.press(value);
|
|
88
|
+
}
|
|
89
|
+
return this.snapshot(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async screenshot(id) {
|
|
93
|
+
const buffer = await this.page(id).screenshot({ type: 'png', timeout: 15000 });
|
|
94
|
+
return { content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'image/png' }] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async closePage(id) {
|
|
98
|
+
await this.page(id).close(); this.pages.delete(id); this.logs.delete(id);
|
|
99
|
+
return { closed: id };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async close() {
|
|
103
|
+
if (this.starting) await this.starting.catch(() => {});
|
|
104
|
+
await this.browser?.close();
|
|
105
|
+
this.browser = null; this.context = null; this.starting = null;
|
|
106
|
+
this.pages.clear(); this.logs.clear();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { quote } from '../system.js';
|
|
4
|
+
|
|
5
|
+
const LIMIT = 256 * 1024;
|
|
6
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
7
|
+
|
|
8
|
+
export class Processes {
|
|
9
|
+
constructor(workspace, env = process.env) { this.workspace = workspace; this.env = env; this.jobs = new Map(); }
|
|
10
|
+
|
|
11
|
+
status(id) {
|
|
12
|
+
const job = this.jobs.get(id);
|
|
13
|
+
if (!job) throw new Error('找不到本会话的进程。');
|
|
14
|
+
return { job_id: id, pid: job.child.pid, running: !job.ended, exit_code: job.code ?? null,
|
|
15
|
+
signal: job.signal ?? null, timed_out: Boolean(job.timedOut), stdout: job.stdout.slice(-8000), stderr: job.stderr.slice(-8000),
|
|
16
|
+
output_truncated: job.truncated || job.stdout.length > 8000 || job.stderr.length > 8000, cwd: job.cwd };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async run(command, cwd = '.', timeoutMs = 30000, background = false, signal) {
|
|
20
|
+
if ([...this.jobs.values()].filter(job => !job.ended).length >= 16) throw new Error('后台进程过多,请先停止部分进程。');
|
|
21
|
+
for (const [id, job] of this.jobs) if (this.jobs.size >= 64 && job.ended) this.jobs.delete(id);
|
|
22
|
+
const directory = await this.workspace.resolve(cwd);
|
|
23
|
+
const shell = this.env.SHELL || '/bin/bash';
|
|
24
|
+
const child = spawn(shell, ['-lc', `cd -- ${quote(directory)} && ${command}`], {
|
|
25
|
+
cwd: directory, env: this.env, detached: true, stdio: ['ignore', 'pipe', 'pipe']
|
|
26
|
+
});
|
|
27
|
+
const id = randomUUID();
|
|
28
|
+
const job = { child, cwd: directory, stdout: '', stderr: '', truncated: false, ended: false };
|
|
29
|
+
this.jobs.set(id, job);
|
|
30
|
+
job.done = new Promise(resolve => {
|
|
31
|
+
child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
|
|
32
|
+
for (const stream of ['stdout', 'stderr']) child[stream].on('data', chunk => {
|
|
33
|
+
if (job[stream].length + chunk.length > LIMIT) job.truncated = true;
|
|
34
|
+
job[stream] = (job[stream] + chunk).slice(-LIMIT);
|
|
35
|
+
});
|
|
36
|
+
child.once('error', error => { job.stderr += error.message; job.code = 127; job.ended = true; resolve(); });
|
|
37
|
+
child.once('close', (code, sig) => { job.code = code; job.signal = sig; job.ended = true; resolve(); });
|
|
38
|
+
});
|
|
39
|
+
if (background) {
|
|
40
|
+
await Promise.race([job.done, delay(80)]);
|
|
41
|
+
return this.status(id);
|
|
42
|
+
}
|
|
43
|
+
const abort = () => this.stop(id).catch(() => {});
|
|
44
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
45
|
+
const timer = setTimeout(() => { job.timedOut = true; abort(); }, timeoutMs);
|
|
46
|
+
try {
|
|
47
|
+
if (signal?.aborted) abort();
|
|
48
|
+
await job.done;
|
|
49
|
+
return this.status(id);
|
|
50
|
+
} finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
killGroup(job, signal) {
|
|
54
|
+
if (!job.child.pid) return;
|
|
55
|
+
try { process.kill(-job.child.pid, signal); }
|
|
56
|
+
catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async stop(id) {
|
|
60
|
+
const job = this.jobs.get(id);
|
|
61
|
+
if (!job) throw new Error('找不到本会话的进程。');
|
|
62
|
+
if (job.ended) return this.status(id);
|
|
63
|
+
// Kill the owned group even if its parent shell has already exited.
|
|
64
|
+
this.killGroup(job, 'SIGTERM');
|
|
65
|
+
await Promise.race([job.done, delay(1500)]);
|
|
66
|
+
this.killGroup(job, 'SIGKILL');
|
|
67
|
+
await Promise.race([job.done, delay(1000)]);
|
|
68
|
+
return this.status(id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
list() { return [...this.jobs.keys()].map(id => {
|
|
72
|
+
const { stdout, stderr, ...status } = this.status(id);
|
|
73
|
+
return { ...status, stdout_characters: this.jobs.get(id).stdout.length, stderr_characters: this.jobs.get(id).stderr.length };
|
|
74
|
+
}); }
|
|
75
|
+
async close() { await Promise.allSettled([...this.jobs.keys()].map(id => this.stop(id))); }
|
|
76
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Workspace } from './workspace.js';
|
|
9
|
+
import { Processes } from './processes.js';
|
|
10
|
+
import { Browser } from './browser.js';
|
|
11
|
+
|
|
12
|
+
const text = value => {
|
|
13
|
+
const output = JSON.stringify(value);
|
|
14
|
+
// Claude persists very large MCP results on the server; local mode cannot use its Read tool.
|
|
15
|
+
const bounded = output.length <= 40000 ? output : JSON.stringify({ truncated: true,
|
|
16
|
+
message: '结果过大,请缩小查询范围或分页读取。本次只返回预览。', preview: output.slice(0, 16000) });
|
|
17
|
+
return { content: [{ type: 'text', text: bounded }] };
|
|
18
|
+
};
|
|
19
|
+
const file = z.string().min(1).max(4096);
|
|
20
|
+
const pageId = z.string().min(1);
|
|
21
|
+
const selector = z.string().min(1).max(4000);
|
|
22
|
+
|
|
23
|
+
export async function startLocalServer({ root, env = process.env, headless = false, browser, log = console.error } = {}) {
|
|
24
|
+
const workspace = new Workspace(await fs.realpath(root));
|
|
25
|
+
const processes = new Processes(workspace, env);
|
|
26
|
+
browser ||= new Browser({ headless, log });
|
|
27
|
+
const token = randomBytes(32).toString('hex');
|
|
28
|
+
const transports = new Set();
|
|
29
|
+
let closing = false;
|
|
30
|
+
|
|
31
|
+
function createServer() {
|
|
32
|
+
const server = new McpServer({ name: 'claude-local-tools', version: '0.2.0' });
|
|
33
|
+
const register = (name, description, inputSchema, handler, readOnly = false) => {
|
|
34
|
+
server.registerTool(name, { description, inputSchema,
|
|
35
|
+
annotations: { readOnlyHint: readOnly, idempotentHint: readOnly } }, async (args, extra) => {
|
|
36
|
+
try {
|
|
37
|
+
const value = await handler(args, extra);
|
|
38
|
+
return Array.isArray(value?.content) ? value : text(value);
|
|
39
|
+
} catch (error) { return { isError: true, content: [{ type: 'text', text: error.message }] }; }
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
register('workspace_info', 'Describe the LOCAL computer and project. All tools execute on this computer, not the SSH server.', {},
|
|
43
|
+
() => ({ root: workspace.root, platform: os.platform(), shell: env.SHELL || '/bin/bash',
|
|
44
|
+
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);
|
|
45
|
+
register('read_file', 'Read a LOCAL project text file, with line numbers and a SHA-256 for safe replacement.', {
|
|
46
|
+
path: file, offset: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(2000).default(200)
|
|
47
|
+
}, args => workspace.read(args.path, args.offset, args.limit), true);
|
|
48
|
+
register('write_file', 'Create a LOCAL file. Replacing an existing file requires its current SHA-256 from read_file.', {
|
|
49
|
+
path: file, content: z.string().max(1024 * 1024), expected_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional()
|
|
50
|
+
}, args => workspace.write(args.path, args.content, args.expected_sha256));
|
|
51
|
+
register('edit_file', 'Edit a LOCAL file by exact text replacement. Include enough context for a unique match.', {
|
|
52
|
+
path: file, old_text: z.string().min(1).max(1024 * 1024), new_text: z.string().max(1024 * 1024), replace_all: z.boolean().default(false)
|
|
53
|
+
}, args => workspace.edit(args.path, args.old_text, args.new_text, args.replace_all));
|
|
54
|
+
register('list_directory', 'List LOCAL project files and directories, including hidden entries.', { path: file.default('.') },
|
|
55
|
+
args => workspace.list(args.path), true);
|
|
56
|
+
register('search_text', 'Search literal text in LOCAL project files. Skips .git, node_modules, .venv, binary and large files.', {
|
|
57
|
+
pattern: z.string().min(1).max(1000), path: file.default('.'), max_results: z.number().int().min(1).max(500).default(100),
|
|
58
|
+
case_sensitive: z.boolean().default(true)
|
|
59
|
+
}, args => workspace.search(args.pattern, args.path, args.max_results, args.case_sensitive), true);
|
|
60
|
+
register('run_command', 'Run a shell command on the LOCAL computer in the project. Git/npm/Docker and other installed CLI tools share this tool. For servers or long jobs set background=true; use process_status for output and stop_process to stop.', {
|
|
61
|
+
command: z.string().min(1).max(100000), cwd: file.default('.'), timeout_ms: z.number().int().min(100).max(120000).default(30000),
|
|
62
|
+
background: z.boolean().default(false)
|
|
63
|
+
}, (args, extra) => processes.run(args.command, args.cwd, args.timeout_ms, args.background, extra.signal));
|
|
64
|
+
register('process_status', 'Read status and bounded stdout/stderr from a process started by this session.', { job_id: z.string() },
|
|
65
|
+
args => processes.status(args.job_id), true);
|
|
66
|
+
register('list_processes', 'List processes owned by this tool session, not all system processes.', {}, () => processes.list(), true);
|
|
67
|
+
register('stop_process', 'Stop a process group started by this tool session, including a development server.', { job_id: z.string() },
|
|
68
|
+
args => processes.stop(args.job_id));
|
|
69
|
+
register('browser_open', 'Open a page in a dedicated LOCAL browser. localhost is the developer computer. Does not attach to existing browser profiles.', {
|
|
70
|
+
url: z.string().url().or(z.literal('about:blank')).default('about:blank')
|
|
71
|
+
}, args => browser.open(args.url));
|
|
72
|
+
register('browser_snapshot', 'Inspect a LOCAL browser page and get visible text and selectors for interactive elements.', { page_id: pageId },
|
|
73
|
+
args => browser.snapshot(args.page_id), true);
|
|
74
|
+
register('browser_click', 'Click a selector on a LOCAL browser page. Use selectors from browser_snapshot.', { page_id: pageId, selector },
|
|
75
|
+
args => browser.action(args.page_id, 'click', args.selector));
|
|
76
|
+
register('browser_fill', 'Fill an input on a LOCAL browser page.', { page_id: pageId, selector, value: z.string().max(100000) },
|
|
77
|
+
args => browser.action(args.page_id, 'fill', args.selector, args.value));
|
|
78
|
+
register('browser_press', 'Press a key such as Enter, Escape or ControlOrMeta+A in a LOCAL page.', {
|
|
79
|
+
page_id: pageId, selector: selector.optional(), key: z.string().min(1).max(100)
|
|
80
|
+
}, args => browser.action(args.page_id, 'press', args.selector, args.key));
|
|
81
|
+
register('browser_screenshot', 'Capture the visible viewport of a LOCAL page as an image.', { page_id: pageId },
|
|
82
|
+
args => browser.screenshot(args.page_id), true);
|
|
83
|
+
register('browser_logs', 'Read recent browser console messages, page errors and failed network requests.', { page_id: pageId },
|
|
84
|
+
args => { browser.page(args.page_id); const logs = browser.logs.get(args.page_id); return { logs: logs.slice(-20), truncated: logs.length > 20 }; }, true);
|
|
85
|
+
register('browser_close', 'Close a LOCAL browser tab opened by this session.', { page_id: pageId }, args => browser.closePage(args.page_id));
|
|
86
|
+
return server;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const httpServer = http.createServer(async (request, response) => {
|
|
90
|
+
const expected = Buffer.from('Bearer ' + token), actual = Buffer.from(request.headers.authorization || '');
|
|
91
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
92
|
+
response.writeHead(401).end('Unauthorized'); return;
|
|
93
|
+
}
|
|
94
|
+
if (closing) { response.writeHead(503).end(); return; }
|
|
95
|
+
// Browser pages cannot invoke this privileged endpoint; native MCP clients do not send Origin.
|
|
96
|
+
if (request.headers.origin) { response.writeHead(403).end('Origin not allowed'); return; }
|
|
97
|
+
if (request.url !== '/mcp') { response.writeHead(404).end(); return; }
|
|
98
|
+
if (request.method !== 'POST') { response.writeHead(405, { Allow: 'POST' }).end(); return; }
|
|
99
|
+
let server, transport;
|
|
100
|
+
try {
|
|
101
|
+
const chunks = []; let bytes = 0;
|
|
102
|
+
for await (const chunk of request) {
|
|
103
|
+
bytes += chunk.length;
|
|
104
|
+
if (bytes > 2 * 1024 * 1024) { response.writeHead(413).end('Request too large'); return; }
|
|
105
|
+
chunks.push(chunk);
|
|
106
|
+
}
|
|
107
|
+
let body;
|
|
108
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
109
|
+
catch { response.writeHead(400).end('Invalid JSON'); return; }
|
|
110
|
+
server = createServer();
|
|
111
|
+
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
112
|
+
transports.add(transport);
|
|
113
|
+
response.on('close', () => {
|
|
114
|
+
transports.delete(transport);
|
|
115
|
+
transport.close().catch(() => {}); server.close().catch(() => {});
|
|
116
|
+
});
|
|
117
|
+
await server.connect(transport);
|
|
118
|
+
await transport.handleRequest(request, response, body);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
await transport?.close().catch(() => {}); await server?.close().catch(() => {});
|
|
121
|
+
if (!response.headersSent) response.writeHead(500).end('MCP request failed');
|
|
122
|
+
else response.end();
|
|
123
|
+
log('本地工具请求失败:' + error.message);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
httpServer.requestTimeout = 30000;
|
|
127
|
+
httpServer.headersTimeout = 10000;
|
|
128
|
+
await new Promise((resolve, reject) => {
|
|
129
|
+
httpServer.once('error', reject); httpServer.listen(0, '127.0.0.1', resolve);
|
|
130
|
+
});
|
|
131
|
+
const port = httpServer.address().port;
|
|
132
|
+
return { port, token, workspace, processes, browser,
|
|
133
|
+
async close() {
|
|
134
|
+
closing = true;
|
|
135
|
+
await Promise.allSettled([...transports].map(transport => transport.close()));
|
|
136
|
+
await Promise.allSettled([processes.close(), browser.close()]);
|
|
137
|
+
httpServer.closeAllConnections();
|
|
138
|
+
await new Promise(resolve => httpServer.close(resolve));
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { execute, exists } from '../system.js';
|
|
7
|
+
import { sshBase } from '../ssh.js';
|
|
8
|
+
|
|
9
|
+
export async function openTunnel(config, localPort, { env = process.env, run = execute } = {}) {
|
|
10
|
+
// Keep the control socket below the short Unix-domain path limit on macOS.
|
|
11
|
+
const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
|
|
12
|
+
const directory = await fs.mkdtemp(path.join(base, 'ccr-'));
|
|
13
|
+
const controlPath = path.join(directory, 'ssh');
|
|
14
|
+
const master = spawn('ssh', [...sshBase(config), '-M', '-S', controlPath, '-o', 'ControlPersist=no',
|
|
15
|
+
'-o', 'ExitOnForwardFailure=yes', '-N', '-T', config.host], { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
16
|
+
let closed = false, errorText = '', spawnError;
|
|
17
|
+
const done = new Promise(resolve => {
|
|
18
|
+
master.once('error', error => { spawnError = error; closed = true; resolve(); });
|
|
19
|
+
master.once('close', () => { closed = true; resolve(); });
|
|
20
|
+
});
|
|
21
|
+
master.stderr.setEncoding('utf8');
|
|
22
|
+
master.stderr.on('data', chunk => { errorText = (errorText + chunk).slice(-8000); process.stderr.write(chunk); });
|
|
23
|
+
const stop = async () => {
|
|
24
|
+
if (!closed) {
|
|
25
|
+
await run('ssh', [...sshBase(config), '-S', controlPath, '-O', 'exit', config.host],
|
|
26
|
+
{ env, capture: true, timeout: 5000, check: false }).catch(() => {});
|
|
27
|
+
master.kill('SIGTERM');
|
|
28
|
+
await Promise.race([done, delay(1000)]);
|
|
29
|
+
if (!closed) { master.kill('SIGKILL'); await done; }
|
|
30
|
+
}
|
|
31
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
32
|
+
};
|
|
33
|
+
const interrupt = () => master.kill('SIGINT');
|
|
34
|
+
process.on('SIGINT', interrupt);
|
|
35
|
+
try {
|
|
36
|
+
const deadline = Date.now() + 120000;
|
|
37
|
+
while (!await exists(controlPath)) {
|
|
38
|
+
if (closed) throw new Error('SSH 工具连接失败:' + (spawnError?.message || errorText.trim() || '连接已关闭'));
|
|
39
|
+
if (Date.now() > deadline) throw new Error('等待 SSH 登录超时。');
|
|
40
|
+
await delay(100);
|
|
41
|
+
}
|
|
42
|
+
const result = await run('ssh', [...sshBase(config), '-S', controlPath, '-O', 'forward',
|
|
43
|
+
'-o', 'ExitOnForwardFailure=yes', '-R', `127.0.0.1:0:127.0.0.1:${localPort}`, config.host],
|
|
44
|
+
{ env, capture: true, timeout: 15000 });
|
|
45
|
+
const port = Number(result.stdout.trim());
|
|
46
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('无法获取 SSH 反向隧道端口。请检查服务器是否允许 TCP 转发。');
|
|
47
|
+
return { controlPath, port, close: stop };
|
|
48
|
+
} catch (error) { await stop(); throw error; }
|
|
49
|
+
finally { process.off('SIGINT', interrupt); }
|
|
50
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
6
|
+
const inside = (root, target) => target === root || target.startsWith(root + path.sep);
|
|
7
|
+
|
|
8
|
+
export class Workspace {
|
|
9
|
+
constructor(root) { this.root = root; }
|
|
10
|
+
|
|
11
|
+
async resolve(input = '.', writing = false) {
|
|
12
|
+
const candidate = path.resolve(this.root, input);
|
|
13
|
+
if (!inside(this.root, candidate)) throw new Error('文件路径必须位于当前项目内。');
|
|
14
|
+
let canonical;
|
|
15
|
+
try { canonical = await fs.realpath(candidate); }
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code !== 'ENOENT' || !writing) throw error;
|
|
18
|
+
let parent = path.dirname(candidate);
|
|
19
|
+
while (true) {
|
|
20
|
+
try { canonical = path.join(await fs.realpath(parent), path.relative(parent, candidate)); break; }
|
|
21
|
+
catch (e) {
|
|
22
|
+
if (e.code !== 'ENOENT' || parent === path.dirname(parent)) throw e;
|
|
23
|
+
parent = path.dirname(parent);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (!inside(this.root, canonical)) throw new Error('符号链接指向项目外部,无法访问。');
|
|
28
|
+
return canonical;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async read(file, offset = 1, limit = 200) {
|
|
32
|
+
const target = await this.resolve(file);
|
|
33
|
+
const stat = await fs.stat(target);
|
|
34
|
+
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error('仅支持读取 2 MiB 以内的文本文件。');
|
|
35
|
+
const content = await fs.readFile(target, 'utf8');
|
|
36
|
+
if (content.includes('\0')) throw new Error('这是二进制文件,请通过终端或专用工具处理。');
|
|
37
|
+
const lines = content.split('\n');
|
|
38
|
+
return { path: path.relative(this.root, target), sha256: hash(content), total_lines: lines.length,
|
|
39
|
+
content: lines.slice(offset - 1, offset - 1 + limit).map((line, i) => `${offset + i}: ${line}`).join('\n') };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async atomicWrite(target, content, mode = 0o644) {
|
|
43
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
44
|
+
const temp = path.join(path.dirname(target), '.claude-write-' + randomUUID());
|
|
45
|
+
try {
|
|
46
|
+
await fs.writeFile(temp, content, { flag: 'wx', mode });
|
|
47
|
+
await fs.rename(temp, target);
|
|
48
|
+
} finally { await fs.rm(temp, { force: true }); }
|
|
49
|
+
return { path: path.relative(this.root, target), sha256: hash(content), bytes: Buffer.byteLength(content) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async write(file, content, expectedSha256) {
|
|
53
|
+
const target = await this.resolve(file, true);
|
|
54
|
+
let mode;
|
|
55
|
+
try {
|
|
56
|
+
if ((await fs.stat(target)).size > 2 * 1024 * 1024) throw new Error('覆盖文件大小上限为 2 MiB。');
|
|
57
|
+
const old = await fs.readFile(target, 'utf8');
|
|
58
|
+
mode = (await fs.stat(target)).mode & 0o777;
|
|
59
|
+
if (!expectedSha256 || hash(old) !== expectedSha256)
|
|
60
|
+
throw new Error('文件已存在或发生变化;先 read_file,再提供匹配的 expected_sha256。');
|
|
61
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
62
|
+
return this.atomicWrite(target, content, mode);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async edit(file, oldText, newText, replaceAll = false) {
|
|
66
|
+
const target = await this.resolve(file);
|
|
67
|
+
const stat = await fs.stat(target);
|
|
68
|
+
if (stat.size > 2 * 1024 * 1024) throw new Error('编辑文件大小上限为 2 MiB。');
|
|
69
|
+
const content = await fs.readFile(target, 'utf8');
|
|
70
|
+
const matches = content.split(oldText).length - 1;
|
|
71
|
+
if (!matches) throw new Error('找不到 old_text,请重新读取文件。');
|
|
72
|
+
if (matches > 1 && !replaceAll) throw new Error('old_text 出现多次,请提供更精确的上下文或使用 replace_all。');
|
|
73
|
+
const next = replaceAll ? content.split(oldText).join(newText) : content.replace(oldText, () => newText);
|
|
74
|
+
return this.atomicWrite(target, next, stat.mode & 0o777);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async list(directory = '.') {
|
|
78
|
+
const target = await this.resolve(directory);
|
|
79
|
+
const entries = await fs.readdir(target, { withFileTypes: true });
|
|
80
|
+
return { path: path.relative(this.root, target) || '.', truncated: entries.length > 500,
|
|
81
|
+
entries: entries.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 500).map(entry => ({
|
|
82
|
+
name: entry.name, type: entry.isSymbolicLink() ? 'symlink' : entry.isDirectory() ? 'directory' : 'file'
|
|
83
|
+
})) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async search(pattern, directory = '.', maxResults = 100, caseSensitive = true) {
|
|
87
|
+
const root = await this.resolve(directory);
|
|
88
|
+
const results = [];
|
|
89
|
+
let scanned = 0, truncated = false;
|
|
90
|
+
const needle = caseSensitive ? pattern : pattern.toLowerCase();
|
|
91
|
+
const walk = async target => {
|
|
92
|
+
if (results.length >= maxResults || scanned >= 10000) { truncated = true; return; }
|
|
93
|
+
const stat = await fs.lstat(target);
|
|
94
|
+
if (stat.isSymbolicLink()) return;
|
|
95
|
+
if (stat.isDirectory()) {
|
|
96
|
+
for (const entry of await fs.readdir(target, { withFileTypes: true })) {
|
|
97
|
+
if (['.git', 'node_modules', '.venv', '__pycache__'].includes(entry.name)) continue;
|
|
98
|
+
await walk(path.join(target, entry.name));
|
|
99
|
+
if (truncated) break;
|
|
100
|
+
}
|
|
101
|
+
} else if (stat.isFile()) {
|
|
102
|
+
scanned++;
|
|
103
|
+
if (stat.size > 1024 * 1024) return;
|
|
104
|
+
const text = await fs.readFile(target, 'utf8');
|
|
105
|
+
if (text.includes('\0')) return;
|
|
106
|
+
for (const [i, line] of text.split('\n').entries()) {
|
|
107
|
+
if ((caseSensitive ? line : line.toLowerCase()).includes(needle)) {
|
|
108
|
+
results.push({ path: path.relative(this.root, target), line: i + 1, text: line.slice(0, 1000) });
|
|
109
|
+
if (results.length >= maxResults) { truncated = true; break; }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
await walk(root);
|
|
115
|
+
return { results, scanned_files: scanned, truncated };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { quote, shellJoin, which } from './system.js';
|
|
5
|
+
import { sshArgs } from './ssh.js';
|
|
6
|
+
import { remotePath } from './client.js';
|
|
7
|
+
import { ensureRemoteClaude, remoteEnvironment } from './remote-claude.js';
|
|
8
|
+
import { startLocalServer } from './local/server.js';
|
|
9
|
+
import { installBrowser } from './local/browser.js';
|
|
10
|
+
import { openTunnel } from './local/tunnel.js';
|
|
11
|
+
|
|
12
|
+
const BOOLEAN_FLAGS = new Set(['--continue', '-c', '--print', '-p', '--verbose', '--no-session-persistence',
|
|
13
|
+
'--include-partial-messages', '--replay-user-messages']);
|
|
14
|
+
const VALUE_FLAGS = new Set(['--model', '--effort', '--max-turns', '--max-budget-usd', '--output-format',
|
|
15
|
+
'--input-format', '--session-id', '--append-system-prompt']);
|
|
16
|
+
const SUBCOMMANDS = new Set(['auth', 'mcp', 'install', 'update', 'doctor', 'agents', 'attach', 'gateway',
|
|
17
|
+
'daemon', 'remote-control', 'rc', 'desktop', 'setup-token']);
|
|
18
|
+
|
|
19
|
+
export function validateLocalArguments(args) {
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const argument = args[i];
|
|
22
|
+
if (argument === '--') break;
|
|
23
|
+
if (!argument.startsWith('-')) {
|
|
24
|
+
if (SUBCOMMANDS.has(argument)) throw new Error('本地模式不执行服务器管理子命令;请用 SSH 管理服务器上的 Claude。');
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const name = argument.split('=')[0];
|
|
28
|
+
if (BOOLEAN_FLAGS.has(name) && argument === name) continue;
|
|
29
|
+
if (VALUE_FLAGS.has(name) || name === '--resume' || name === '-r') {
|
|
30
|
+
if (argument.includes('=')) continue;
|
|
31
|
+
if (name === '--resume' || name === '-r') { if (args[i + 1] && !args[i + 1].startsWith('-')) i++; continue; }
|
|
32
|
+
if (args[i + 1] === undefined || args[i + 1].startsWith('-')) throw new Error(`${name} 缺少参数;以短横线开头的值请使用 ${name}=值。`);
|
|
33
|
+
i++; continue;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`本地模式暂不支持参数 ${name};工具配置由本地模式统一管理。`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function localClaudeArguments(configFile, root, extra) {
|
|
40
|
+
validateLocalArguments(extra);
|
|
41
|
+
return ['--restricted', '--tools', '', '--strict-mcp-config', '--mcp-config', configFile,
|
|
42
|
+
'--setting-sources', '', '--disable-slash-commands', '--settings', JSON.stringify({ disableAllHooks: true, enabledPlugins: {} }),
|
|
43
|
+
'--append-system-prompt', `All project operations run on the developer's LOCAL computer through the local MCP server. ` +
|
|
44
|
+
`The LOCAL project root is ${JSON.stringify(root)}. The server working directory is only for session metadata, not project files. ` +
|
|
45
|
+
'Call workspace_info before working. Use only local tools for file access, shell commands and browser operations. ' +
|
|
46
|
+
'Read LOCAL CLAUDE.md with read_file if present. Use background commands for development servers, inspect their logs, and stop owned processes when finished. ' +
|
|
47
|
+
'If the local connection fails, report it and stop tool work; never fall back to server tools.', ...extra];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function supportsLocalMode(output) {
|
|
51
|
+
const match = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
|
|
52
|
+
if (!match) return false;
|
|
53
|
+
const [major, minor, patch] = match.slice(1).map(Number);
|
|
54
|
+
return major > 2 || major === 2 && (minor > 1 || minor === 1 && patch >= 248);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function launchLocal(client, config, extra, dependencies = {}) {
|
|
58
|
+
validateLocalArguments(extra);
|
|
59
|
+
if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
|
|
60
|
+
const root = await fs.realpath(client.cwd), home = await fs.realpath(client.home);
|
|
61
|
+
if (root === home || root === path.parse(root).root) throw new Error('请进入具体项目目录,不允许以用户目录或磁盘根目录作为项目。');
|
|
62
|
+
const prepareBrowser = dependencies.installBrowser || installBrowser;
|
|
63
|
+
await prepareBrowser(client.log);
|
|
64
|
+
const gateway = await (dependencies.startLocalServer || startLocalServer)({ root, env: client.env,
|
|
65
|
+
headless: client.env.CLAUDE_REMOTE_HEADLESS === undefined ? Boolean(config.browserHeadless) : client.env.CLAUDE_REMOTE_HEADLESS === '1', log: client.log });
|
|
66
|
+
let tunnel, configFile;
|
|
67
|
+
const hash = createHash('sha256').update(config.client_id + '\0' + root).digest('hex').slice(0, 24);
|
|
68
|
+
const remoteDirectory = config.root + '/local-' + hash;
|
|
69
|
+
const remote = (script, { interactive = false, input, capture = !interactive, timeout = 30000, check = true } = {}) =>
|
|
70
|
+
client.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && client.tty,
|
|
71
|
+
input: input !== undefined, controlPath: tunnel.controlPath }), { env: client.env, capture, input,
|
|
72
|
+
timeout: interactive ? 0 : timeout, check });
|
|
73
|
+
try {
|
|
74
|
+
tunnel = await (dependencies.openTunnel || openTunnel)(config, gateway.port, { env: client.env, run: client.execute });
|
|
75
|
+
const version = await remote(ensureRemoteClaude(config.claude) + shellJoin([config.claude, '--version']), { timeout: 600000 });
|
|
76
|
+
if (!supportsLocalMode(version.stdout)) throw new Error('本地工具模式需要服务器 Claude Code 2.1.248 或更高版本。请先在服务器运行 claude update。');
|
|
77
|
+
configFile = remoteDirectory + '/.local-mcp-' + randomUUID() + '.json';
|
|
78
|
+
const mcp = { mcpServers: { local: { type: 'http', url: `http://127.0.0.1:${tunnel.port}/mcp`,
|
|
79
|
+
headers: { Authorization: 'Bearer ' + gateway.token }, timeout: 180000 } } };
|
|
80
|
+
await remote('set -e; umask 077; mkdir -p -- ' + remotePath(remoteDirectory) + '; cat > ' + remotePath(configFile),
|
|
81
|
+
{ input: JSON.stringify(mcp) });
|
|
82
|
+
client.log(`本地执行模式:${root}\n文件、命令和浏览器在本机运行;项目文件不通过本模式同步。`);
|
|
83
|
+
const args = localClaudeArguments(path.posix.basename(configFile), root, extra);
|
|
84
|
+
// Keep the parent shell so EXIT can remove the token-bearing temporary config.
|
|
85
|
+
const script = remoteEnvironment + 'cd -- ' + remotePath(remoteDirectory) + ' || exit 1; ' +
|
|
86
|
+
'trap ' + quote('rm -f -- ' + remotePath(configFile)) + ' EXIT; ' +
|
|
87
|
+
shellJoin([config.claude, ...args]);
|
|
88
|
+
return (await remote(script, { interactive: true, check: false })).code;
|
|
89
|
+
} finally {
|
|
90
|
+
if (tunnel && configFile) await remote('rm -f -- ' + remotePath(configFile), { timeout: 5000, check: false }).catch(() => {});
|
|
91
|
+
await gateway.close();
|
|
92
|
+
await tunnel?.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { quote } from './system.js';
|
|
2
|
+
|
|
3
|
+
export const remoteEnvironment = 'export PATH="$HOME/.local/bin:$PATH"; ';
|
|
4
|
+
|
|
5
|
+
export function ensureRemoteClaude(command) {
|
|
6
|
+
const missing = command === 'claude' ? `
|
|
7
|
+
echo '服务器未安装 Claude Code,正在执行官方安装流程……' >&2
|
|
8
|
+
(
|
|
9
|
+
set -e
|
|
10
|
+
installer=$(mktemp "\${TMPDIR:-/tmp}/claude-remote-install.XXXXXXXX")
|
|
11
|
+
trap 'rm -f -- "$installer"' EXIT
|
|
12
|
+
if command -v curl >/dev/null 2>&1; then
|
|
13
|
+
curl -fSL --connect-timeout 20 --max-time 120 --retry 2 \\
|
|
14
|
+
--output "$installer" https://claude.ai/install.sh
|
|
15
|
+
elif command -v wget >/dev/null 2>&1; then
|
|
16
|
+
wget --timeout=120 --tries=3 --output-document="$installer" https://claude.ai/install.sh
|
|
17
|
+
else
|
|
18
|
+
echo '自动安装需要服务器提供 curl 或 wget,请先安装其中一个工具。' >&2
|
|
19
|
+
exit 127
|
|
20
|
+
fi
|
|
21
|
+
if ! bash "$installer" >&2; then
|
|
22
|
+
echo 'Claude Code 官方安装失败,请检查上述输出、网络连接和目录权限后重试。' >&2
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
)
|
|
26
|
+
hash -r
|
|
27
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
28
|
+
echo '安装器已退出,但仍找不到 Claude Code。请检查 ~/.local/bin/claude 或通过 --claude 指定绝对路径。' >&2
|
|
29
|
+
exit 127
|
|
30
|
+
fi
|
|
31
|
+
echo 'Claude Code 已安装,首次启动时请按提示完成账号认证。' >&2
|
|
32
|
+
` : `
|
|
33
|
+
echo ${quote('找不到配置的 Claude 程序:' + command + '。请修正 --claude 路径,或配置 --claude claude 使用自动安装。')} >&2
|
|
34
|
+
exit 127
|
|
35
|
+
`;
|
|
36
|
+
// A failed download/installer must stop before any project directory is created or synced.
|
|
37
|
+
return 'set -e\n' + remoteEnvironment + '\n' +
|
|
38
|
+
`if ! command -v ${quote(command)} >/dev/null 2>&1; then\n${missing}fi\n`;
|
|
39
|
+
}
|
package/src/ssh.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { quote } from './system.js';
|
|
2
|
+
|
|
3
|
+
export function sshBase(config) {
|
|
4
|
+
const args = ['-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'];
|
|
5
|
+
if (config.port != null) args.push('-p', String(config.port));
|
|
6
|
+
return args;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function sshArgs(config, script, { interactive = false, tty = false, input = false, controlPath } = {}) {
|
|
10
|
+
const args = sshBase(config);
|
|
11
|
+
if (!interactive && !input) args.push('-n');
|
|
12
|
+
if (tty) args.push('-tt');
|
|
13
|
+
if (controlPath) args.push('-S', controlPath, '-o', 'ControlMaster=no');
|
|
14
|
+
args.push(config.host, 'bash -lc ' + quote(script));
|
|
15
|
+
return args;
|
|
16
|
+
}
|
package/src/system.js
CHANGED
|
@@ -38,9 +38,14 @@ export async function writeJSON(file, value) {
|
|
|
38
38
|
} finally { await fs.rm(temp, { force: true }); }
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function execute(command, args = [], { capture = false, timeout = 0, check = true, env = process.env } = {}) {
|
|
41
|
+
export function execute(command, args = [], { capture = false, timeout = 0, check = true, env = process.env, input } = {}) {
|
|
42
42
|
return new Promise((resolve, reject) => {
|
|
43
|
-
const child = spawn(command, args, { stdio:
|
|
43
|
+
const child = spawn(command, args, { stdio: [input !== undefined ? 'pipe' : capture ? 'ignore' : 'inherit',
|
|
44
|
+
capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit'], env });
|
|
45
|
+
if (input !== undefined) {
|
|
46
|
+
child.stdin.on('error', () => {}); // An early remote failure is reported by the exit status.
|
|
47
|
+
child.stdin.end(input);
|
|
48
|
+
}
|
|
44
49
|
let stdout = '', stderr = '', timedOut = false, overflow = false, killTimer;
|
|
45
50
|
const interrupt = () => child.kill('SIGINT');
|
|
46
51
|
process.on('SIGINT', interrupt);
|